/*
 * Unidata Platform Community Edition
 * Copyright (c) 2013-2020, UNIDATA LLC, All rights reserved.
 * This file is part of the Unidata Platform Community Edition software.
 *
 * Unidata Platform Community Edition is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Unidata Platform Community Edition is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */

package org.unidata.mdm.search.type.form;

import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DateFormatUtils;
import org.unidata.mdm.search.type.FieldType;
import org.unidata.mdm.search.type.GenericIndexField;
import org.unidata.mdm.search.type.IndexField;
import org.unidata.mdm.search.type.search.SearchField;

/**
 * @author Mikhail Mikhailov
 *         Search form field.
 */
public class FormField extends GenericIndexField implements SearchField {
    /**
     * Value is single object.
     */
    private static final int VALUE_SINGLE = 1 << 0;
    /**
     * Value is a list.
     */
    private static final int VALUE_LIST = 1 << 1;
    /**
     * Value is a list object.
     */
    private static final int VALUE_RANGE = 1 << 2;
    /**
     * Filtering type - positive or negative.
     */
    public enum FilteringType {
        NEGATIVE,
        POSITIVE
    }
    /**
     * Search type on this field.
     */
    public enum SearchType {
        DEFAULT,        // Match
        EXACT,          // Term
        FUZZY,          // Term with fuzziness
        LEVENSHTEIN,    // Term with L-D distance match
        MORPHOLOGICAL,  // Match on morphologically analyzed ($morph) field
        EXIST,          // Value exists
        START_WITH,     // Match with prefix
        LIKE,
        RANGE,
        NONE_MATCH
    }
    /**
     * The value.
     */
    @Nullable
    private final Object value;
    /**
     * The value type.
     */
    private final int valueType;
    /**
     * form type
     */
    @Nonnull
    private final FilteringType formType;
    /**
     * Search type
     */
    private final SearchType searchType;
    /**
     * Constructor for collection.
     */
    private FormField(@Nonnull FieldType type, @Nonnull String path, @Nonnull FilteringType formType,
                      @Nullable Collection<?> values, @Nonnull SearchType searchType, boolean analyzed) {
        super(type, path, analyzed);
        this.value = convertToType(values, path, type);
        this.formType = formType;
        this.searchType = searchType;
        this.valueType = VALUE_LIST;
    }
    /**
     * Constructor for single.
     */
    private FormField(@Nonnull FieldType type, @Nonnull String path, @Nonnull FilteringType formType,
                      @Nullable Object single, @Nonnull SearchType searchType, boolean analyzed) {
        super(type, path, analyzed);
        this.value =  convertToType(single, path, type);
        this.formType = formType;
        this.searchType = searchType;
        this.valueType = VALUE_SINGLE;
    }
    /**
     * Constructor for ranges.
     */
    private FormField(@Nonnull FieldType type, @Nonnull String path, @Nonnull FilteringType formType, @Nonnull Range range) {
        super(type, path, false);
        this.formType = formType;
        this.value = range;
        this.searchType = SearchType .RANGE;
        this.valueType = VALUE_RANGE;
    }

    private static Object convertToType(Object value, @Nonnull String path, @Nonnull FieldType type) {

        if (value == null) {
            return null;
        }

        if (path.startsWith("$")) {
            return value;
        }

        if (type == FieldType.TIME) {
            return DateFormatUtils.ISO_TIME_FORMAT.format(value);
        } else if (type == FieldType.DATE) {
            return DateFormatUtils.ISO_DATE_FORMAT.format(value);
        } else if (type == FieldType.TIMESTAMP) {
            return DateFormatUtils.ISO_DATETIME_FORMAT.format(value);
        }

        return value;
    }

    private static List<Object> convertToType(Collection<?> values, @Nonnull String path, @Nonnull FieldType type) {

        if (CollectionUtils.isEmpty(values)) {
            return Collections.emptyList();
        }

        return values.stream()
                .map(v -> FormField.convertToType(v, path, type))
                .collect(Collectors.toList());
    }
    /**
     * Construct field from given attributes.
     * @param valueType the field's type
     * @param filterType the {@link FilteringType#NEGATIVE} or {@link FilteringType#POSITIVE}
     * @param searchType one of the {@link SearchType} values
     * @param path the path
     * @param analyzed analyzed mark for string values
     * @param value the value
     * @return new field
     */
    public static FormField of(@Nonnull FieldType valueType, @Nonnull FilteringType filterType, SearchType searchType,
            @Nonnull String path, boolean analyzed, @Nullable Object value) {

        Object v = value;
        if (FieldType.STRING == valueType) {
            v = value == null || StringUtils.isBlank(value.toString()) ? null : value;
        }

        return new FormField(valueType, path, filterType, v, searchType, analyzed);
    }
    /**
     * Construct field from index field and other attributes.
     * @param f the field
     * @param filter the {@link FilteringType#NEGATIVE} or {@link FilteringType#POSITIVE}
     * @param search one of the {@link SearchType} values
     * @param value the value
     * @return new field
     */
    public static FormField of(@Nonnull IndexField f, @Nonnull FilteringType filter, SearchType search, @Nullable Object value) {
        return of(f.getFieldType(), filter, search, f.getPath(), f.isAnalyzed(), value);
    }
    /**
     * Construct field from given attributes.
     * @param valueType the field's type
     * @param filterType the {@link FilteringType#NEGATIVE} or {@link FilteringType#POSITIVE}
     * @param searchType one of the {@link SearchType} values
     * @param path the path
     * @param analyzed analyzed mark for string values
     * @param value the value
     * @return new field
     */
    public static FormField of(@Nonnull FieldType valueType, @Nonnull FilteringType filterType, SearchType searchType,
            @Nonnull String path, boolean analyzed, @Nullable Collection<?> value) {

        Collection<?> v = value;
        if (FieldType.STRING == valueType) {
            v = CollectionUtils.isEmpty(value)
                ? Collections.emptyList()
                : value.stream()
                    .filter(Objects::nonNull)
                    .map(Object::toString)
                    .collect(Collectors.toList());
        }

        return new FormField(valueType, path, filterType, v, searchType, analyzed);
    }
    /**
     * Construct field from index field and other attributes.
     * @param f the field
     * @param filter the {@link FilteringType#NEGATIVE} or {@link FilteringType#POSITIVE}
     * @param search one of the {@link SearchType} values
     * @param value the value
     * @return new field
     */
    public static FormField of(@Nonnull IndexField f, @Nonnull FilteringType filter, SearchType search, @Nullable Collection<?> value) {
        return of(f.getFieldType(), filter, search, f.getPath(), f.isAnalyzed(), value);
    }
    /**
     * @param type     - type of data
     * @param path     -  field name
     * @param analyzed - analyzed field or not
     * @param single   - value
     * @return form field for strict value
     */
    public static FormField exact(@Nonnull FieldType type, @Nonnull String path, boolean analyzed, @Nullable Object single) {
        return of(type, FilteringType.POSITIVE, SearchType.EXACT, path, analyzed, single);
    }
    /**
     * @param path      - field name
     * @param analyzed  - analyzed field or not
     * @param single    - value
     * @return form field for strict string
     */
    public static FormField exact(@Nonnull String path, boolean analyzed, @Nullable String single) {
        return exact(FieldType.STRING, path, analyzed, single);
    }
    /**
     * Excat boolean value.
     * @param path the path
     * @param value the value
     * @return field
     */
    public static FormField exact(@Nonnull String path, boolean value) {
        return exact(FieldType.BOOLEAN, path, false, Boolean.valueOf(value));
    }
    /**
     * Value from field spec.
     * @param type     - type of data
     * @param path     -  field name
     * @param single   - value
     * @return form field for exact value
     */
    public static FormField exact(@Nonnull IndexField f, @Nullable Object single) {
        return of(f, FilteringType.POSITIVE, SearchType.EXACT, single);
    }
    /**
     * Exact value from field spec.
     * @param type      - type of data
     * @param path      - field name
     * @param analyzed  - analyzed field or not
     * @param values    - value list
     * @return form field for exact value
     */
    public static FormField exact(@Nonnull FieldType type, @Nonnull String path, boolean analyzed, @Nonnull Collection<?> values) {
        return of(type, FilteringType.POSITIVE, SearchType.EXACT, path, analyzed, values);
    }
    /**
     * Values from field spec.
     * @param type     - type of data
     * @param path     -  field name
     * @param single   - value
     * @return form field for strict value
     */
    public static FormField exact(@Nonnull IndexField f, @Nonnull Collection<?> values) {
        return of(f, FilteringType.POSITIVE, SearchType.EXACT, values);
    }
    /**
     * Creates exact negate field.
     * @param type      - type of data
     * @param path      - field name
     * @param analyzed  - analyzed field or not
     * @param single    - value
     * @return form field for strict value
     */
    public static FormField exactNegate(@Nonnull FieldType type, @Nonnull String path, boolean analyzed, @Nullable Object single) {
        return of(type, FilteringType.NEGATIVE, SearchType.EXACT, path, analyzed, single);
    }
    /**
     * Creates exact negate field.
     * @param type   - type of data
     * @param path   -  field name
     * @param single - value
     * @return form field for strict value
     */
    public static FormField exactNegate(@Nonnull IndexField f, @Nullable Object single) {
        return of(f, FilteringType.NEGATIVE, SearchType.EXACT, single);
    }
    /**
     * Creates fuzzy search request for given field definition.
     * @param type     - type of data
     * @param path     - field name
     * @param analyzed - analyzed field or not
     * @param single   - the value
     * @return form field for fuzzy search value
     */
    public static FormField fuzzy(@Nonnull FieldType type, @Nonnull String path, boolean analyzed, @Nullable Object single) {
        return of(type, FilteringType.POSITIVE, SearchType.FUZZY, path, analyzed, single);
    }
    /**
     * Creates fuzzy search request for given field definition.
     * @param f         - the field definition
     * @param single    - the value
     * @return form field for fuzzy search value
     */
    public static FormField fuzzy(@Nonnull IndexField f, @Nullable Object single) {
        return of(f, FilteringType.POSITIVE, SearchType.FUZZY, single);
    }
    /**
     * Creates L-D fuzzy query for given field definition.
     * @param path      - the field path
     * @param analyzed  - analyzed mark
     * @param single    - the value
     * @return form field for L-D search value
     */
    public static FormField levenshtein(@Nonnull String path, boolean analyzed, @Nullable String single) {
        String value = StringUtils.isBlank(single)  ? null : single;
        return of(FieldType.STRING, FilteringType.POSITIVE, SearchType.LEVENSHTEIN, path, analyzed, value);
    }
    /**
     * Creates L-D fuzzy query for given field definition.
     * @param f         - the field definition
     * @param single    - the value
     * @return form field for L-D search value
     */
    public static FormField levenshtein(@Nonnull IndexField f, @Nullable String single) {
        return of(f, FilteringType.POSITIVE, SearchType.LEVENSHTEIN, single);
    }
    /**
     * Creates morphology aware query for given field definition.
     * @param analyzed  - analyzed field mark
     * @param single    - the value
     * @param f         - the field definition
     * @return form field for morphology aware search value
     */
    public static FormField morphological(@Nonnull String path, boolean analyzed, @Nullable String single) {
        return of(FieldType.STRING, FilteringType.POSITIVE, SearchType.MORPHOLOGICAL, path, analyzed, single);
    }
    /**
     * Creates L-D fuzzy query for given field definition.
     * @param f         - the field definition
     * @param single    - the value
     * @return form field for L-D search value
     */
    public static FormField morphological(@Nonnull IndexField f, @Nullable String single) {
        return of(f, FilteringType.POSITIVE, SearchType.MORPHOLOGICAL, single);
    }
    /**
     * Creates prefix term level query for the given field definition.
     * @param path      - field path
     * @param analyzed  - analyzed field mark
     * @param single    - value
     * @return form field for start with string
     */
    public static FormField startsWith(@Nonnull String path, boolean analyzed, @Nullable String single) {
        return of(FieldType.STRING, FilteringType.POSITIVE, SearchType.START_WITH, path, analyzed, single);
    }
    /**
     * Creates prefix term level query for the given field definition.
     * @param f         - the field definition
     * @param single    - the value
     * @return form field for start with string
     */
    public static FormField startsWith(@Nonnull IndexField f, @Nullable String single) {
        return of(f, FilteringType.POSITIVE, SearchType.START_WITH, single);
    }
    /**
     * Creates inverse prefix term-level query for the given field definition.
     * @param path      - field name
     * @param analyzed  - analyzed field mark
     * @param single    - value
     * @return form field for not start with string
     */
    public static FormField startsNotWith(@Nonnull String path, boolean analyzed, @Nullable String single) {
        return of(FieldType.STRING, FilteringType.NEGATIVE, SearchType.START_WITH, path, analyzed, single);
    }
    /**
     * Creates inverse prefix term-level query for the given field definition.
     * @param f         - the field definition
     * @param single    - value
     * @return form field for not start with string
     */
    public static FormField startsNotWith(@Nonnull IndexField f, @Nullable String single) {
        return of(f, FilteringType.NEGATIVE, SearchType.START_WITH, single);
    }
    /**
     * Something like this value.
     * @param path      - field path
     * @param analyzed  - analyzed field mark
     * @param single    - value
     * @return form field for like string
     */
    public static FormField like(@Nonnull String path, boolean analyzed, @Nullable String single) {
        return of(FieldType.STRING, FilteringType.POSITIVE, SearchType.LIKE, path, analyzed, single);
    }
    /**
     * Something like this value.
     * @param f         - the field definition
     * @param single    - the value
     * @return form field for like string
     */
    public static FormField like(@Nonnull IndexField f, @Nullable String single) {
        return of(f, FilteringType.POSITIVE, SearchType.LIKE, single);
    }
    /**
     * Something other then this value.
     * @param path      - field name
     * @param analyzed  - analyzed field mark
     * @param single    - value
     * @return form field for not like string
     */
    public static FormField likeNot(@Nonnull String path, boolean analyzed, @Nullable String single) {
        // remove all ? and *
        String value = StringUtils.isBlank(single)
                ? null
                : "*" + single.replace("*", "\\*").replace("?", "\\?") + "*";
        return of(FieldType.STRING, FilteringType.NEGATIVE, SearchType.LIKE, path, analyzed, value);
    }
    /**
     * Something other then this value.
     * @param path      - field name
     * @param analyzed  - analyzed field mark
     * @param single    - value
     * @return form field for not like string
     */
    public static FormField likeNot(@Nonnull IndexField f, @Nullable String single) {
        // remove all ? and *
        String value = StringUtils.isBlank(single)
                ? null
                : "*" + single.replace("*", "\\*").replace("?", "\\?") + "*";
        return of(f, FilteringType.NEGATIVE, SearchType.LIKE, value);
    }
    /**
     * None matches.
     * @return form field for empty results
     */
    public static FormField noneMatch() {
        return of(FieldType.ANY, FilteringType.POSITIVE, SearchType.NONE_MATCH, StringUtils.EMPTY, false, (Object) null);
    }
    /**
     * Filter for empty values.
     * @param path - field name
     * @return form field for empty results
     */
    public static FormField empty(@Nonnull String path) {
        return of(FieldType.ANY, FilteringType.NEGATIVE, SearchType.EXIST, path, false, (Object) null);
    }
    /**
     * Filter for empty values.
     * @param f - the field
     * @return form field for empty results
     */
    public static FormField empty(@Nonnull IndexField f) {
        return empty(f.getPath());
    }
    /**
     * Filter for existing values.
     * @param path - field path
     * @return form field for not empty results
     */
    public static FormField notEmpty(@Nonnull String path) {
        return of(FieldType.ANY, FilteringType.POSITIVE, SearchType.EXIST, path, false, (Object) null);
    }
    /**
     * Filter for existing values.
     * @param path - field path
     * @return form field for not empty results
     */
    public static FormField notEmpty(@Nonnull IndexField f) {
        return notEmpty(f.getPath());
    }
    /**
     * Filter values, which are in range.
     * @param type          - type of data
     * @param path          - field name
     * @param filter      -  type of field
     * @param left  - left boundary value
     * @param right - right boundary value
     * @return form field for range
     */
    public static FormField range(@Nonnull FieldType type, @Nonnull String path, @Nonnull FilteringType filter,
            @Nullable Object left, @Nullable Object right) {

        Object l = left;
        Object r = right;
        if (FieldType.STRING == type) {
            l = left == null || left.toString().isEmpty() ? null : left;
            r = right == null || right.toString().isEmpty() ? null : right;
        }

        return new FormField(type, path, filter, new Range(convertToType(l, path, type), convertToType(r, path, type)));
    }
    /**
     * @param type          - type of data
     * @param path          - field name
     * @param left  - left boundary value
     * @param right - right boundary value
     * @return form field for range
     */
    public static FormField range(@Nonnull FieldType type, @Nonnull String path, @Nullable Object left, @Nullable Object right) {
        return range(type, path, FilteringType.POSITIVE, left, right);
    }
    /**
     * @param f          - the field
     * @param path       - field name
     * @param left  - left boundary value
     * @param right - right boundary value
     * @return form field for range
     */
    public static FormField range(@Nonnull IndexField f, @Nullable Object left, @Nullable Object right) {
        return range(f, FilteringType.POSITIVE, left, right);
    }
    /**
     * @param f          - the field
     * @param path       - field name
     * @param left  - left boundary value
     * @param right - right boundary value
     * @return form field for range
     */
    public static FormField range(@Nonnull IndexField f, @Nonnull FilteringType formType, @Nullable Object left, @Nullable Object right) {

        Object l = left;
        Object r = right;
        if (FieldType.STRING == f.getFieldType()) {
            l = left == null || left.toString().isEmpty() ? null : left;
            r = right == null || right.toString().isEmpty() ? null : right;
        }

        return new FormField(f.getFieldType(), f.getPath(), formType,
               new Range(convertToType(l, f.getPath(), f.getFieldType()), convertToType(r, f.getPath(), f.getFieldType())));
    }
    /**
     * Filter for value not in range.
     * @param type      - type of data
     * @param path      - field name
     * @param left      - left boundary value
     * @param right     - right boundary value
     * @return form field for not a range
     */
    public static FormField notInRange(@Nonnull FieldType type, @Nonnull String path, @Nullable Object left, @Nullable Object right) {
        return range(type, path, FilteringType.NEGATIVE, left, right);
    }
    /**
     * Filter for value not in range.
     * @param type      - type of data
     * @param path      - field name
     * @param left      - left boundary value
     * @param right     - right boundary value
     * @return form field for not a range
     */
    public static FormField notInRange(@Nonnull IndexField f, @Nullable Object left, @Nullable Object right) {
        return range(f, FilteringType.NEGATIVE, left, right);
    }
    /**
     * Create copy for form field with inverter FormType (negative ~ positive)
     * @param forCopy form field for copy
     * @return form field
     */
    public static FormField invert(@Nonnull FormField forCopy) {

        if (forCopy.isMultiValue()) {
            return new FormField(forCopy.getFieldType(),
                    forCopy.getPath(),
                    forCopy.getFormType() == FilteringType.POSITIVE ? FilteringType.NEGATIVE : FilteringType.POSITIVE,
                    forCopy.getValues(),
                    forCopy.getSearchType(),
                    forCopy.isAnalyzed());
        } else if (forCopy.isRangeValue()) {
            return new FormField(forCopy.getFieldType(),
                    forCopy.getPath(),
                    forCopy.getFormType() == FilteringType.POSITIVE ? FilteringType.NEGATIVE : FilteringType.POSITIVE,
                    forCopy.getRange());
        } else if (forCopy.isSingleValue()) {
            return new FormField(forCopy.getFieldType(),
                    forCopy.getPath(),
                    forCopy.getFormType() == FilteringType.POSITIVE ? FilteringType.NEGATIVE : FilteringType.POSITIVE,
                    forCopy.getSingleValue(),
                    forCopy.getSearchType(),
                    forCopy.isAnalyzed());
        }

        return null;
    }
    /**
     * Remaps an existing form field to a new path.
     * @param newPath the path to remap to
     * @param from the field definition
     * @return new firld
     */
    public static FormField remap(String newPath, FormField from) {

        if (from.isMultiValue()) {
            return new FormField(from.getFieldType(),
                    newPath,
                    from.getFormType(),
                    from.getValues(),
                    from.getSearchType(),
                    from.isAnalyzed());
        } else if (from.isRangeValue()) {
            return new FormField(from.getFieldType(),
                    newPath,
                    from.getFormType(),
                    from.getRange());
        } else if (from.isSingleValue()) {
            return new FormField(from.getFieldType(),
                    newPath,
                    from.getFormType(),
                    from.getSingleValue(),
                    from.getSearchType(),
                    from.isAnalyzed());
        }

        return null;
    }
    /**
     * Tells whether this form denotes null value.
     *
     * @return true if so, false otherwise
     */
    public boolean isNull() {
        return Objects.isNull(value);
    }
    /**
     * Tells whether this field holds a range value (Range).
     * @return true for range, false otherwise
     */
    public boolean isRangeValue(){
        return (valueType & VALUE_RANGE) != 0;
    }
    /**
     * Tells whether this field holds multiple values (List).
     * @return true for list, false otherwise
     */
    public boolean isMultiValue(){
        return (valueType & VALUE_LIST) != 0;
    }
    /**
     * Tells whether this field holds a single value (Object).
     * @return true for single, false otherwise
     */
    public boolean isSingleValue(){
        return (valueType & VALUE_SINGLE) != 0;
    }
    /**
     * @return the range
     */
    public Range getRange() {

        if ((valueType & VALUE_RANGE) != 0) {
            return (Range) value;
        }

        return null;
    }
    /**
     * List values.
     */
    @SuppressWarnings("unchecked")
    public List<Object> getValues() {

        if ((valueType & VALUE_LIST) != 0) {
            return (List<Object>) value;
        }

        return Collections.emptyList();
    }
    /**
     * @return the single
     */
    public Object getSingleValue() {

        if ((valueType & VALUE_SINGLE) != 0) {
            return value;
        }

        return null;
    }
    /**
     * @return type of form which impact query type.
     */
    @Nonnull
    public FilteringType getFormType() {
        return formType;
    }
    /**
     * Gets the selected search type.
     * @return the search type
     */
    public SearchType getSearchType() {
        return searchType;
    }
    /**
     * The simplest possible range.
     */
    public static class Range {

        private final Object leftBoundary;
        private final Object rightBoundary;

        public Range(Object leftBoundary, Object rightBoundary) {
            this.leftBoundary = leftBoundary;
            this.rightBoundary = rightBoundary;
        }

        public Object getLeftBoundary() {
            return leftBoundary;
        }

        public Object getRightBoundary() {
            return rightBoundary;
        }
    }
}
